在現代 Vue.js 應用程序開發中,處理異步操作和 API 請求是常見且關鍵的任務。本文將深入探討如何使用 Vitest 來全面測試這些異步行為和 API 請求邏輯。我們將整合 TypeScript、Vue Router、Pinia、Zod、Vee-Validate、@vueuse/core 等技術,並特別關注 createFetch
的使用。通過實際例子,我們將展示如何處理各種 API 請求場景,包括文件上傳、下載、表單提交、SSE、WebSocket 等。
這篇文章我們要把 day13 最後的自定義 fetch api
composable,作為測試目標
以及達成可測試目標作為目的做修改
在這個 api 我們簡單看一下架構以及核心概念。我們會用 curry function
把每個個別流程串聯再一起,讓我們可以針對個別的 method 進行單元測試
以下 curry function 簡單展示
const makeCurryFn = <T>(
input: T,
fnList: ((input: T) => T)[]
): T => fnList.reduce((acc, fn) => fn(acc), input);
使用方式:
(檔案: src/composables/useApiFrame.ts
)
import { BeforeFetchContext } from "@vueuse/core";
export interface InputOptions {
input1: string;
input2: string;
}
const makeCurryFn = <T>(
input: T,
fnList: ((input: T) => T)[]
): T => fnList.reduce((acc, fn) => fn(acc), input);
const pipeLine1 = (input: string): (ctx: BeforeFetchContext) => BeforeFetchContext => {
return (ctx) => {
console.log(input);
// do something 1...
return ctx;
}
};
const pipeLine2 = (input: string): (ctx: BeforeFetchContext) => BeforeFetchContext => {
return (ctx) => {
console.log(input);
// do something 2...
return ctx;
}
};
const allMethodCombine = (options: InputOptions): (ctx: BeforeFetchContext) => BeforeFetchContext => {
const { input1, input2 } = options;
return (ctx) => makeCurryFn<BeforeFetchContext>(ctx, [pipeLine1(input1),pipeLine2(input2)]);
};
以上範例展示,也因為這樣我們對於 useApiFetch
的結構有初步認識了
所以可以把複雜的架構簡化
import { AfterFetchContext, BeforeFetchContext, createFetch, OnFetchErrorContext } from '@vueuse/core';
export const useApiFrame = (
beforeFetch: (ctx: BeforeFetchContext) => BeforeFetchContext,
afterFetch: (ctx: AfterFetchContext) => AfterFetchContext,
onFetchError: (ctx: OnFetchErrorContext) => OnFetchErrorContext,
) => {
const useApi = () => createFetch({
baseUrl: `${import.meta.env.VITE_APP_API_URL ?? ''}`,
options: {
timeout: 30000,
beforeFetch,
afterFetch,
onFetchError,
},
fetchOptions: {
mode: 'cors',
}
})
return {
useApi,
};
};
export type UseApiFrame = typeof useApiFrame;
並且把型別拆分
(檔案:src/schema/api.ts
)
import * as zod from 'zod'
import { MaybeRef } from 'vue';
export type RequestInput = string | number | boolean | File;
export type RequestInputs = RequestInput | RequestInput[];
export type RequestDataStructureInputs = RequestInputs | Record<string, RequestInputs> | Record<string, RequestInputs>[];
export type RequestJsonInputs = Record<string, RequestDataStructureInputs> | Record<string, RequestDataStructureInputs>[];
export interface UseApiFetchOptions {
isBearerTokenRequired?: boolean
query?: MaybeRef<Record<string, RequestInputs>>
json?: MaybeRef<RequestJsonInputs>
formData?: MaybeRef<Record<string, RequestInputs>>
responseSchema?: zod.ZodTypeAny
errorResponseSchema?: zod.ZodTypeAny
}
這樣我們可以根據個別的 method 進行測試
測試 function
或 method
我們可以根據預期的方法進行測試 input 爲什麼 output 預計爲什麼,去預判每個方法符合預期
如何執行可以參考過去的測試範例,這裡緊做簡單的示範
這裡我們把 beforeFetch
的功能分割開來
(檔案: src/composables/useApiBeforeFetch.ts
)
import { MaybeRef, toValue } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import { AfterFetchContext, BeforeFetchContext } from "@vueuse/core";
import { RequestJsonInputs } from '../schema/api';
export const useApiBeforeFetch = () => {
const getAuthorizationBeforeFetch = (isTokenRequired: boolean = false): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
const token = useLocalStorage('token', '');
if (!isTokenRequired)
return noActionContext<BeforeFetchContext>;
return (ctx: BeforeFetchContext) => {
if (!token) {
ctx.cancel();
return ctx;
}
ctx.options.headers = {
...ctx.options.headers,
Authorization: `Bearer ${token}`
};
return ctx;
};
};
const getQueryBeforeFetch = <T extends string | number | boolean | File>(
query?: MaybeRef<Record<string, T | T[]>>
): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
if (!query)
return noActionContext<BeforeFetchContext>;
const currentQuery = toValue(query);
if (!currentQuery)
return noActionContext<BeforeFetchContext>;
if (Object.keys(currentQuery).length === 0)
return noActionContext<BeforeFetchContext>;
return (ctx: BeforeFetchContext): BeforeFetchContext => {
ctx.url += `?${generateQueryString(currentQuery)}`;
return ctx;
};
}
const getJsonFormatBeforeFetch = (
jsonInput?: MaybeRef<RequestJsonInputs>
): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
if (!jsonInput)
return noActionContext<BeforeFetchContext>;
const currentRawData = toValue(jsonInput);
if (!currentRawData)
return noActionContext<BeforeFetchContext>;
return (ctx: BeforeFetchContext): BeforeFetchContext => {
ctx.options.headers = {
...ctx.options.headers,
'Content-Type': 'application/json'
};
ctx.options.body = JSON.stringify(removeNullishInRecursiveObject(currentRawData));
return ctx;
};
};
const noActionContext = <T extends BeforeFetchContext | AfterFetchContext>(ctx: T): T => ctx;
const generateQueryString = <T extends string | number | boolean | File>(queryData: Record<string, T | T[]>): string => {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(queryData)) {
if (Array.isArray(value)) {
value.forEach(el => query.append(key, el.toString()));
continue;
}
if (checkIsNotEmpty(value)) {
query.append(key, value.toString());
}
}
const queryString = query.toString();
return queryString.length > 0 ? `${queryString}` : '';
};
const checkIsNotEmpty = (val: unknown) => {
if (typeof val === 'number' || typeof val === 'boolean')
return true;
return val !== '' && typeof val !== 'undefined';
};
const removeNullishInRecursiveObject = (obj: RequestJsonInputs): RequestJsonInputs => {
if (Array.isArray(obj)) {
return obj.map(el => removeNullishInRecursiveObject(el)) as RequestJsonInputs;
}
// if obj is boolean or number
if (isAllowBooleanNumberString(obj))
return obj;
// if obj is object but not array
const entries = Object.entries(obj)
.filter(([, v]) => {
if (!isAllowBooleanNumberAndObject)
return isNotEmpty(v);
return true;
})
.map(([k, v]) => {
if (Array.isArray(v)) {
return [
k,
v.filter(el => isNotEmptyExcludeEmptyString(el)).map(el => removeNullishInRecursiveObject(el as RequestJsonInputs))
];
}
if (isFile(v))
return [k, v];
if (typeof v === 'object') {
return [k, removeNullishInRecursiveObject(v)];
}
return [k, v];
});
return Object.fromEntries(entries);
};
const isNotEmpty = (v: unknown): boolean => {
return v !== '' && isNotEmptyExcludeEmptyString(v);
};
const isNotEmptyExcludeEmptyString = (v: unknown): boolean => {
return v !== undefined && v !== null;
};
const isAllowBooleanNumber = (v: unknown): boolean => {
if (typeof v === 'boolean')
return true;
return typeof v === 'number';
};
const isAllowBooleanNumberString = (v: unknown): boolean => {
return typeof v === 'string' || isAllowBooleanNumber(v);
};
const isAllowBooleanNumberAndObject = (v: unknown): boolean => {
if (isAllowBooleanNumber(v))
return true;
if (typeof v === 'object')
return true;
return false;
};
const isFile = (input: unknown): input is File => {
return input instanceof File;
};
const convertFormDataResult = <T extends string | number | boolean | File>(input: T): File | string => {
if (isFile(input))
return input;
return input.toString();
};
const getFormDataFormatBeforeFetch = <T extends string | number | boolean | File>(
formDataInput?: MaybeRef<Record<string, T | T[]>>
): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
if (!formDataInput)
return noActionContext<BeforeFetchContext>;
const currentRawData = toValue(formDataInput);
if (!currentRawData)
return noActionContext<BeforeFetchContext>;
return (ctx: BeforeFetchContext): BeforeFetchContext => {
ctx.options.body = convertObjectToFormData(currentRawData);
return ctx;
};
};
const convertObjectToFormData = <T extends string | number | boolean | File>(obj: Record<string, T | T[]>): FormData => {
const formData = new FormData();
for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) {
value.forEach(el => formData.append(key, convertFormDataResult(el)));
continue;
}
if (value === null || typeof value === 'undefined') {
continue;
}
formData.append(key, convertFormDataResult(value));
}
return formData;
};
return {
getAuthorizationBeforeFetch,
getQueryBeforeFetch,
getJsonFormatBeforeFetch,
getFormDataFormatBeforeFetch,
};
};
export type UseApiBeforeFetch = typeof useApiBeforeFetch;
afterFetch
和 onFetchError
也是,但因為篇幅原因所以這裡不示範
所以可以整合成
import { AfterFetchContext, BeforeFetchContext, OnFetchErrorContext } from '@vueuse/core';
import { useApiFrame } from './useApiFrame';
import { useApiBeforeFetch } from './useApiBeforeFetch';
import { useApiAfterFetch } from './useApiAfterFetch';
import { useApiErrorFetch } from './useApiErrorFetch';
import { UseApiFetchOptions } from '../schema/api'
export const useApiFetch = () => {
// state::
const {
getAuthorizationBeforeFetch,
getQueryBeforeFetch,
getJsonFormatBeforeFetch,
getFormDataFormatBeforeFetch,
} = useApiBeforeFetch();
const {
responseSchemaAfterFetch,
errorSchemaAfterFetch
} = useApiAfterFetch();
const getBeforeFetch = (option: UseApiFetchOptions) => (ctx: BeforeFetchContext) => BeforeFetchContext => {
const { isBearerTokenRequired, query, json, formData } = option;
return (ctx: BeforeFetchContext) => {
return makeCurryFn<BeforeFetchContext>(ctx, [
getAuthorizationBeforeFetch(isBearerTokenRequired),
getQueryBeforeFetch(query),
getJsonFormatBeforeFetch(json),
getFormDataFormatBeforeFetch(formData),
])
}
};
const getAfterFetch = (options: UseApiFetchOptions): ((ctx: AfterFetchContext) => AfterFetchContext) => {
const { responseSchema, errorResponseSchema } = options;
return (ctx: AfterFetchContext) =>
makeCurryFn<AfterFetchContext>(ctx, [
responseSchemaAfterFetch(responseSchema),
errorSchemaAfterFetch(errorResponseSchema)
]);
};
const getErrorFetch = (options: UseApiFetchOptions): ((ctx: OnFetchErrorContext) => OnFetchErrorContext) => {
return (ctx: OnFetchErrorContext) =>
makeCurryFn<OnFetchErrorContext>(ctx, [
]);
};
const { useApi } = (options: UseApiFetchOptions) => useApiFrame(
getBeforeFetch(options),
getAfterFetch(options),
getErrorFetch(options)
)
const makeCurryFn = <T>(
input: T,
fnList: ((input: T) => T)[]
): T => fnList.reduce((acc, fn) => fn(acc), input);
return {
useApi
};
};
export type UseApiFetch = typeof useApiFetch;
如果要 mock BeforeEach
const getBeforeFetch = (option: UseApiFetchOptions) => (ctx: BeforeFetchContext) => BeforeFetchContext => {
const { isBearerTokenRequired, query, json, formData } = option;
return (ctx: BeforeFetchContext) => {
return makeCurryFn<BeforeFetchContext>(ctx, [
getAuthorizationBeforeFetch(isBearerTokenRequired),
getQueryBeforeFetch(query),
getJsonFormatBeforeFetch(json),
getFormDataFormatBeforeFetch(formData),
])
}
};
轉化成
const getBeforeFetch = (option: UseApiFetchOptions) => (ctx: BeforeFetchContext) => BeforeFetchContext => {
const { isBearerTokenRequired, query, json, formData } = option;
return (ctx: BeforeFetchContext) => {
return makeCurryFn<BeforeFetchContext>(ctx, [
getMockAuthorizationBeforeFetch(isBearerTokenRequired),
getMockQueryBeforeFetch(query),
getMockJsonFormatBeforeFetch(json),
getMockkFormDataFormatBeforeFetch(formData),
])
}
};
並針對 input
, output
去預期結果即可
import { useEventSource, useWebSocket } from '@vueuse/core'
export const useSSE = (url: string) => {
const { data, error } = useEventSource(url)
return { data, error }
}
export const useWS = (url: string) => {
const { data, send, open, close } = useWebSocket(url)
return { data, send, open, close }
}
對前端來說可以用 url
去修改成測試 server 進行測試方法是否如預期
export const getDynamicResource = (resourceType: string, id: number) =>
apiRequest<unknown>(`/${resourceType}/${id}`)()
export const searchResources = (resourceType: string, query: Record<string, string>) => {
const queryString = new URLSearchParams(query).toString()
return apiRequest<unknown[]>(`/${resourceType}?${queryString}`)()
}
現在,讓我們為這些功能編寫全面的測試:
// api.test.ts
import { describe, it, expect, vi } from 'vitest'
import { getUser, createUser, updateUser, uploadFile, downloadFile, useSSE, useWS, getDynamicResource, searchResources } from './api'
import { UserSchema } from './types'
vi.mock('@vueuse/core', () => ({
createFetch: () => () => ({
json: () => Promise.resolve({ data: { id: 1, name: 'John Doe', email: 'john@example.com' } })
}),
useEventSource: vi.fn(),
useWebSocket: vi.fn()
}))
describe('API functions', () => {
it('should fetch a user', async () => {
const user = await getUser(1)(UserSchema)
expect(user).toEqual({ id: 1, name: 'John Doe', email: 'john@example.com' })
})
it('should create a user', async () => {
const newUser = { name: 'Jane Doe', email: 'jane@example.com' }
const user = await createUser(newUser)(UserSchema)
expect(user).toEqual({ id: 1, name: 'John Doe', email: 'john@example.com' })
})
it('should update a user', async () => {
const updatedUser = { name: 'John Updated' }
const user = await updateUser(1, updatedUser)(UserSchema)
expect(user).toEqual({ id: 1, name: 'John Doe', email: 'john@example.com' })
})
it('should upload a file', async () => {
const mockFile = new File([''], 'test.txt', { type: 'text/plain' })
const result = await uploadFile(mockFile)
expect(result).toEqual({ fileUrl: 'https://example.com/file.txt' })
})
it('should handle SSE', () => {
vi.mocked(useEventSource).mockReturnValue({
data: ref('test data'),
error: ref(null)
})
const { data, error } = useSSE('https://api.example.com/sse')
expect(data.value).toBe('test data')
expect(error.value).toBeNull()
})
it('should handle WebSocket', () => {
const mockSend = vi.fn()
const mockOpen = vi.fn()
const mockClose = vi.fn()
vi.mocked(useWebSocket).mockReturnValue({
data: ref('test message'),
send: mockSend,
open: mockOpen,
close: mockClose
})
const { data, send, open, close } = useWS('wss://api.example.com/ws')
expect(data.value).toBe('test message')
expect(send).toBe(mockSend)
expect(open).toBe(mockOpen)
expect(close).toBe(mockClose)
})
it('should handle dynamic routes', async () => {
const result = await getDynamicResource('products', 1)(zod.object({ id: zod.number(), name: zod.string() }))
expect(result).toEqual({ id: 1, name: 'Product 1' })
})
it('should handle query strings', async () => {
const result = await searchResources('products', { category: 'electronics', price: 'low' })(zod.object({ id: zod.number(), name: odz.string() }).arry()))
expect(result).toEqual([{ id: 1, name: 'Product 1' }, { id: 2, name: 'Product 2' }])
})
})
import { describe, it, expect, vi } from 'vitest'
import { useUser } from './useUser'
import * as api from './api'
vi.mock('./api')
describe('useUser', () => {
it('should fetch user', async () => {
vi.mocked(api.getUser).mockResolvedValue({ id: 1, name: 'John Doe', email: 'john@example.com' })
const { user, loading, error, fetchUser } = useUser()
await fetchUser(1)
expect(user.value).toEqual({ id: 1, name: 'John Doe', email: 'john@example.com' })
expect(loading.value).toBe(false)
expect(error.value).toBeNull()
})
it('should handle fetch error', async () => {
vi.mocked(api.getUser).mockRejectedValue(new Error('Network error'))
const { user, loading, error, fetchUser } = useUser()
await fetchUser(1)
expect(user.value).toBeNull()
expect(loading.value).toBe(false)
expect(error.value).toBe('Failed to fetch user')
})
// Add more tests for saveUser...
})
在本文中,我們深入探討了如何使用 Vitest 來全面測試 Vue 3 應用中的異步行為和 API 請求邏輯。我們涵蓋了從基本的 API 請求測試到複雜的 WebSocket 和 SSE 實時數據處理。
關鍵點包括:
createFetch
和 curry functions 創建靈活的 API 請求函數。(個人不小心把篇幅寫太多了,目前刪減過)
在實際開發中,你可能需要根據具體的業務邏輯來調整和擴展這些測試。記住,好的測試不僅能捕捉錯誤,還能幫助你更好地理解和改進代碼。通過持續的測試和優化,你可以創建一個既高效又可靠的 Vue 3 應用。